JVM(Java Virtual Machine)

知识点

1. JVM 内存结构

JVM 的内存结构大致分为以下几个区域:

  • 方法区(Method Area):用于存储类信息、常量、静态变量和即时编译器编译后的代码。JVM 规范要求方法区是一个共享内存区域,各个线程都可以访问。
  • 堆区(Heap):用于存储所有的对象实例(类实例)。堆是垃圾回收(GC)的主要区域,GC 会对堆中的对象进行内存回收。
  • 栈区(Stack):每个线程都会有一个独立的栈,用于存储局部变量、方法调用、返回地址等信息。每当方法被调用时,栈会分配一个栈帧,栈帧中存储方法的局部变量、操作数栈等。
  • 程序计数器(Program Counter Register):指向当前线程执行的字节码指令的地址。每个线程都有独立的程序计数器。
  • 本地方法栈(Native Method Stack):为 JNI(Java Native Interface)调用提供服务,存储 Java 调用本地方法时的参数和返回值。

2. JVM 生命周期

JVM 生命周期大致包括以下几个阶段:

  • 启动阶段:启动 JVM 时,加载 Java 类,初始化 JVM 环境。
  • 加载阶段:JVM 启动后,首先加载用户指定的主类。
  • 执行阶段:JVM 执行主类中的 main 方法,并开始解释执行字节码,或者在有 JIT 编译器时进行编译。
  • 结束阶段:当 main 方法执行完毕或 JVM 发生异常终止时,JVM 结束运行,释放资源。

3. 主流虚拟机

主流的 JVM 实现包括:

  • HotSpot JVM:Oracle 提供的官方 JVM,实现了大部分 JVM 规范,支持 JIT 编译。
  • OpenJ9 JVM:由 IBM 开发的 JVM,支持开源和商用,性能优化针对特定场景(如云计算环境)。
  • GraalVM:是一个多语言虚拟机,可以执行 Java、JavaScript、Ruby 等多种语言的代码,支持高效的 JIT 编译和 AOT 编译。
  • Zulu/OpenJDK:是 Oracle JDK 的开源实现,主要用于在 Linux、Windows 和 macOS 上运行 Java 应用。

4. Java 代码执行流程

  • 编译阶段:Java 源代码(.java 文件)通过 javac 编译器编译成字节码(.class 文件)。
  • 类加载:字节码通过类加载器加载到内存中。
  • 字节码解释:JVM 的解释器将字节码转换为平台相关的机器码进行执行。
  • JIT 编译:JVM 通过 JIT 编译将热点代码(经常执行的代码)编译为本地机器码,优化执行效率。

5. 类加载与类加载器

  • 类加载器是 JVM 中用来加载类的组件。它负责将字节码文件加载到 JVM 中,并将它们转化为类对象,供后续使用。
  • 类加载过程:类加载过程包括以下几个步骤:
    1. 加载:通过类加载器将类的字节码加载到内存。
    2. 验证:验证加载的字节码是否符合 JVM 的规范。
    3. 准备:为类的静态变量分配内存,并设定初始值。
    4. 解析:解析类中的符号引用,转化为直接引用。
    5. 初始化:执行类的初始化代码(如静态代码块)。
  • 双亲委派机制:JVM 中的类加载器采用双亲委派模型,即当一个类加载器收到加载请求时,它会先委托给父类加载器去加载,只有在父类加载器无法加载时,子类加载器才会自己加载。这样可以避免重复加载和类冲突。

6. 垃圾回收与垃圾回收策略

  • 垃圾回收器:负责回收 JVM 堆中不再使用的对象,释放内存。常见的垃圾回收器有:
    • Serial GC:单线程垃圾回收器,适用于单核处理器。
    • Parallel GC:多线程垃圾回收器,适用于多核处理器。
    • G1 GC:用于低延迟和大堆内存的场景,适合大规模的应用。
  • 垃圾回收策略:JVM 使用不同的垃圾回收策略,通常根据堆的大小、对象的生命周期以及应用的要求来选择。
  • 垃圾回收算法
    • 标记-清除算法:标记所有需要回收的对象,然后清除这些对象。
    • 复制算法:将堆分为两个区域,每次回收时将存活的对象复制到另一区域。
    • 标记-压缩算法:标记需要回收的对象并进行压缩,避免产生内存碎片。
    • 分代回收:根据对象的存活时间,将对象分为不同的代,分别进行回收。
  • Stop-the-World:是指垃圾回收过程中,所有应用线程被暂停,直到垃圾回收完成。

7. 字节码

字节码是 Java 源代码编译后的中间代码,它是与平台无关的。JVM 通过解释执行或 JIT 编译将字节码转化为机器码,进而执行。

8. 内存分配与回收

  • 堆内存分配:JVM 会将内存分配给对象实例,堆是内存分配的主要区域。
  • 栈内存分配:每个线程都会有一个栈,用于存储方法调用和局部变量。栈内存一般是线程私有的。

9. JVM 性能调优

  • 性能分析方法:通过监控 JVM 的运行状况(如 GC 日志、内存使用情况)来分析性能瓶颈。
  • 常用工具
    • jconsole:用于监控和管理 JVM。
    • VisualVM:可视化的 JVM 监控工具,适合性能调优。
    • GC Logs:查看垃圾回收的日志,分析垃圾回收的效率和停顿时间。
  • 参数设置:通过 JVM 启动参数来调节堆大小、GC 策略等参数,优化性能。例如,-Xmx 设置最大堆大小,-XX:+UseG1GC 启用 G1 垃圾回收器。
  • Java 探针:用于在生产环境中动态监控 JVM 的健康状态。
  • 线上故障分析:在生产环境中,通过 JVM 日志和性能数据分析故障,找出性能瓶颈或异常问题。

问题

1. JVM 内存模型(JMM)

JVM 内存模型(Java Memory Model,简称 JMM)是 Java 并发编程的核心概念之一,它定义了不同线程之间如何共享内存,并且规定了内存操作的顺序,以保证多线程并发程序的正确性。JMM 主要涉及以下几个方面:

JMM 的目标

JMM 的目标是提供一个统一的规则,确保多线程环境下,线程之间的数据交换和同步不会引发竞态条件、内存可见性问题和指令重排问题。通过合理的内存模型,Java 能够保证并发程序的可靠性和一致性。

JMM 的主要概念

  1. 主内存(Main Memory)和工作内存(Working Memory)
    • 主内存:每个线程的共享内存区域,存储着所有变量的值。所有的实例字段、静态字段和数组元素都存储在主内存中。
    • 工作内存:每个线程有自己的工作内存,工作内存中存放的是从主内存中拷贝过来的变量的副本。线程对共享变量的操作通常是在工作内存中进行的,然后再同步回主内存。
  2. 内存可见性(Visibility)
    • 线程的工作内存中的变量修改对其他线程是否可见是一个重要问题。在 JMM 中,变量的修改是否能被其他线程看到,取决于同步操作。比如,在没有适当同步的情况下,一个线程对变量的修改可能永远不会被其他线程看到。
    • 同步关键字(synchronized)volatile 关键字以及 等都可以确保内存可见性。
  3. 有序性(Ordering)
    • 由于编译器、处理器等优化技术,程序中操作的执行顺序可能会与代码中的顺序不同,这被称为指令重排。JMM 通过保证一定的规则来控制指令的执行顺序,以保证程序的正确性。
    • happens-before 规则:JMM 定义了线程间的执行顺序,通过一些同步机制来保证某些操作先于其他操作执行。比如,synchronizedvolatile 就提供了 happens-before 保证,确保某些操作的先后顺序。
  4. 原子性(Atomicity)
    • JMM 还保证了对某些操作的原子性。在 JMM 中,简单的读写操作是原子的,但是像复合操作(例如:i++)这样的操作不是原子性的,可能会导致并发问题。

JMM 中的同步机制

为了保证线程间的内存可见性和操作顺序,Java 提供了几种同步机制:

  1. synchronized:Java 的 synchronized 关键字可以用于方法或代码块,确保同一时刻只有一个线程能执行被同步修饰的代码块,并且同步块中的变量对其他线程是可见的。
  2. volatilevolatile 关键字确保每次访问变量时都直接从主内存中读取,而不是从线程的工作内存中读取。它提供了轻量级的同步机制,但只保证单个变量的可见性,而不提供原子性或顺序性保证。
  3. 锁(Lock):Java 通过显式锁(如 ReentrantLock)来提供更多的控制,保证同一时刻只有一个线程能访问某些共享资源。

JMM 的关键问题

  1. 内存可见性问题

    线程之间的变量修改在不同的工作内存中不会立刻同步到主内存,可能导致其他线程看不到最新的变量值。这是 JMM 需要解决的一个关键问题。

  2. 指令重排问题

    为了提高程序执行的效率,编译器和 CPU 可能会将代码中的指令顺序重排,导致多线程程序的执行顺序与程序源代码中的顺序不一致,进而可能出现逻辑错误。JMM 使用 happens-before 规则来保证线程操作的有序性,避免指令重排带来的问题。

  3. 原子性问题

    多线程环境中对共享资源的操作可能出现竞态条件,导致操作不是原子的。为了保证操作的原子性,Java 提供了同步机制。

JMM 的重要性

理解 JMM 对于编写并发程序至关重要。它帮助开发者理解多线程环境中线程之间是如何共享数据的,以及如何通过同步机制保证程序的正确性。掌握 JMM 能够有效避免线程安全问题和并发 bug。

结论:

JVM 内存模型是 Java 并发编程的基础,它通过对线程间内存共享、操作顺序、内存可见性、指令重排等问题进行详细定义和控制,确保多线程程序的正确性。通过 synchronizedvolatile、锁等机制,Java 提供了丰富的工具,帮助开发者在复杂的并发环境中编写高效、可靠的代码。

2.JVM 内存为什么要分代

JVM 堆内存被分为 年轻代老年代永久代(或元空间),这是为了优化垃圾回收。年轻代主要用于存放新生对象,老年代存放存活时间较长的对象,垃圾回收可以针对年轻代和老年代进行不同的策略,以提高回收效率。

JVM 的堆内存分代(Generation)是为了优化垃圾回收(GC)过程,提升内存管理的效率。在传统的 Java 程序中,程序的对象生命周期有很大的差异,一些对象可能仅存在短暂的时间,而另一些对象则会长时间存活。因此,将堆内存划分为多个区域,可以使垃圾回收器更加高效地处理这些不同生命周期的对象。

JVM 将堆内存分为以下几个主要区域:

  1. 年轻代(Young Generation)
    • 年轻代是存储新生对象的区域。Java 程序通常会创建大量短暂存活的对象,这些对象的生命周期很短,适合放在年轻代中。年轻代的回收通常很频繁,因为大部分对象在创建后不久就会被垃圾回收。
    • 年轻代本身又可以分为三个部分:Eden 空间(新对象的创建区域)、Survivor 0Survivor 1(存放经过一次或多次 GC 仍然存活的对象)。
  2. 老年代(Old Generation)
    • 老年代存储那些存活时间较长的对象。一般来说,年轻代中的对象如果经过多次垃圾回收仍然存活,就会被晋升到老年代。老年代的垃圾回收相对较少,因为其中的对象一般较为稳定。
    • 老年代的回收成本较高,因此会使用不同的垃圾回收策略(如 Full GC)来进行回收。
  3. 元空间(Metaspace)
    • 从 Java 8 开始,方法区被移到了元空间,用于存储类的元数据(如类信息、常量池、方法信息等)。元空间位于本地内存中,不再占用堆内存。
    • 类的元数据通常是长时间存活的,因此不需要频繁回收。

为什么要分代?

  1. 优化垃圾回收的效率
    • 对象的生命周期:在 Java 程序中,大部分对象是短暂存在的,这些对象在创建后很快就会被销毁。垃圾回收器如果每次都检查整个堆内存,会造成大量的性能开销。通过将对象按照生命周期进行分代,可以使垃圾回收器只针对年轻代进行频繁的回收,而将老年代的回收频率降低,从而提高回收效率。
    • 年轻代 GC 的频繁性:年轻代的垃圾回收(Minor GC)通常是快速的,因为它只回收生命周期短、较为简单的对象。与此不同,老年代的垃圾回收(Major GCFull GC)较为复杂且耗时。因此,分代能够使年轻代和老年代的垃圾回收策略和频率有所区别,避免不必要的性能浪费。
  2. 减少 GC 停顿时间
    • 年轻代的快速回收:因为大部分对象的生命周期较短,年轻代中的对象会很快被回收。通过频繁地对年轻代进行垃圾回收,JVM 能够减少长时间停顿的情况,提升程序的响应性和并发性能。
    • 老年代的回收优化:老年代的对象通常需要较长时间才能被回收,因此不需要频繁的垃圾回收。老年代的回收(如 Full GC)虽然耗时较长,但发生频率较低,从而避免频繁的全局回收。
  3. 内存分配优化
    • 快速分配空间:在年轻代中,新的对象会快速地分配内存。这是因为年轻代使用了 Eden 空间 和两个 Survivor 空间 的设计。每当发生 Minor GC 时,存活的对象会从 Eden 区转移到 Survivor 区,经过多次 GC 后再晋升到老年代。
    • 对象晋升和空间回收:通过代际划分,JVM 可以根据对象的年龄来决定它们应当存储在哪个区域,这样可以避免长期存活的对象占用过多的内存空间,同时通过回收短生命周期的对象来减轻内存压力。
  4. 垃圾回收策略的灵活性
    • 针对不同代的不同回收策略:年轻代和老年代的垃圾回收有不同的策略。例如,年轻代使用复制算法(Copying)来实现高效的垃圾回收,而老年代则通常使用标记-清除(Mark-Sweep)和标记-压缩(Mark-Compact)等算法来减少内存碎片。分代使得 JVM 能够灵活地选择适合每个代的回收算法。

总之,JVM 将堆内存分为年轻代、老年代和元空间是为了提高垃圾回收的效率、减少停顿时间,并根据对象的生命周期选择适当的回收策略。通过这种分代机制,JVM 能够更高效地管理内存,保证多线程并发程序的性能。

3. 介绍一次完整的 GC 流程

  • 标记阶段:首先标记所有需要回收的对象。
  • 清除阶段:删除被标记的对象,释放内存。
  • 整理阶段:移动对象,避免内存碎片。
  • 这个流程在不同的垃圾回收器中可能略有不同,但大体相似。

一次完整的 GC 流程

垃圾回收(GC)是 JVM 中用于自动管理内存的机制。JVM 通过垃圾回收器来清理堆内存中的不再使用的对象,从而释放内存。GC 主要集中在堆内存的回收上,尤其是年轻代和老年代的管理。下面是一次完整的垃圾回收(GC)流程的详细介绍:

1. GC 触发的时机

垃圾回收的触发通常有以下几种情况:

  • 年轻代空间不足:当年轻代的 Eden 区空间不足时,JVM 会触发一次 Minor GC(年轻代垃圾回收)。这是最常见的 GC 类型,回收的对象大多是生命周期较短的对象。
  • 老年代空间不足:当老年代空间不足时,JVM 会触发一次 Full GC(完全垃圾回收),这通常是一次较长的回收过程。此时不仅回收年轻代,还会回收老年代和永久代(或元空间)。
  • JVM 手动触发:通过调用 System.gc()Runtime.getRuntime().gc() 可以手动触发垃圾回收,但这通常不推荐在生产环境中使用。

2. GC 类型

在 JVM 中,垃圾回收主要分为以下几种类型:

  1. Minor GC(年轻代 GC):
    • 触发条件:年轻代的 Eden 区空间不足时会触发 Minor GC。
    • 处理区域:只回收年轻代的对象(Eden 区和 Survivor 区)。
    • 执行速度:由于年轻代的对象通常生命周期较短,回收时大多数对象都会被清除,所以 Minor GC 执行较为高效,暂停时间较短。
  2. Major GC 或 Full GC(老年代 GC):
    • 触发条件:当老年代空间不足时触发 Full GC。
    • 处理区域:不仅回收年轻代,还会回收老年代和永久代(或元空间)。
    • 执行速度:由于老年代中存活的对象较多,回收时需要更长的时间,暂停时间较长。

3. 完整 GC 流程

(1)Minor GC 过程(年轻代)

Minor GC 是在年轻代内存空间不足时触发的回收过程,主要涉及以下步骤:

  • 标记阶段(Mark)
    • 在这个阶段,JVM 会遍历年轻代中的对象,标记所有可达(存活)对象。可达的对象是那些从根对象(如栈中的局部变量、静态字段等)能够直接或间接访问到的对象。
    • 标记阶段的目的是为了确定哪些对象是可以回收的。
  • 清除阶段(Sweep)
    • 在标记阶段完成后,JVM 会清除掉所有未标记的对象,即那些不可达的对象。这些对象被认为是垃圾,可以从堆中回收。
  • 压缩阶段(Compact)
    • 为了避免内存碎片,JVM 会将存活的对象压缩到一起。这样不仅清理了不再使用的对象,还保证了堆内存的连续性,从而提高内存的使用效率。
  • 对象晋升
    • 在 Minor GC 结束时,存活的对象会从年轻代的 Survivor 区转移到老年代(如果这些对象已经存活了多次 GC)。这意味着一些“长期存活”的对象会被晋升到老年代。

(2)Full GC 过程(老年代)

Full GC 是在老年代内存不足时触发的垃圾回收,过程更加复杂,主要包括:

  • 标记阶段(Mark)
    • 类似于 Minor GC,JVM 会标记出所有存活的对象。标记的对象会被认为是可达的,这些对象不需要被回收。
  • 清除阶段(Sweep)
    • 清除所有不可达的对象,将其从内存中回收。对于老年代来说,通常这一步操作会比较慢,因为老年代存活的对象比较多。
  • 压缩阶段(Compact)
    • 经过清理的内存区域会被压缩,回收的空间被整合起来,避免碎片问题。此步骤的目的是减少内存碎片,优化老年代的内存分配。
  • 类的卸载(如果有永久代/元空间)
    • 如果垃圾回收器发现永久代或元空间中有不再使用的类,它也会卸载这些类,释放相关的内存。

(3)JVM 对象的晋升

在垃圾回收过程中,JVM 会根据对象的存活时间和回收情况,将年轻代中存活的对象晋升到老年代。晋升的条件通常是:

  • 对象在年轻代经过一定次数的 GC 仍然存活。
  • 对象的年龄达到了老年代的晋升阈值。

这个机制有助于减少老年代的频繁回收,从而提高 GC 的效率。

(4)Stop-The-World

在垃圾回收过程中,JVM 会发生 Stop-the-World 事件,意味着所有应用程序的线程都会暂停,直到 GC 完成。具体的暂停时间会根据垃圾回收的种类和堆的大小而有所不同。Minor GC 的暂停时间通常较短,而 Full GC 的暂停时间则较长。

(5)GC 日志与分析

在进行垃圾回收时,JVM 会生成 GC 日志,记录每次 GC 的详细信息,包括垃圾回收的类型(Minor/Full)、回收前后的内存情况、回收时间等。通过分析这些日志,开发者可以监控 JVM 的内存使用情况,判断是否需要调整堆大小、垃圾回收策略等。

4. GC 策略与优化

JVM 提供了多种垃圾回收策略,可以根据不同应用的需求进行调整:

  • Serial GC:单线程垃圾回收,适用于内存小、单核 CPU 环境。
  • Parallel GC:多线程垃圾回收,适用于多核 CPU 环境,可以提高垃圾回收效率。
  • G1 GC:适用于大堆内存和低延迟场景,能够将回收过程分成多个小步骤,减少停顿时间。
  • ZGCShenandoah GC:低延迟垃圾回收器,适用于对响应时间要求极高的应用。

总结

一次完整的垃圾回收流程包括标记、清除、压缩等步骤。首先进行 Minor GC 回收年轻代对象,当年轻代空间不足时触发;如果老年代内存不足,则会触发 Full GC,进行全堆的回收。垃圾回收器通过这些过程确保 JVM 堆内存中的对象能够被高效管理,并最大化地利用内存。

4. 介绍双亲委派模型,为什么需要它?

双亲委派机制是类加载器的一种结构,它确保了类的加载顺序,避免了类加载的冲突和重复加载。通过委派机制,子类加载器优先将加载请求传递给父类加载器,从而保证了核心类库的安全性和一致性。

双亲委派模型(Parent Delegation Model)

双亲委派模型是 Java 类加载机制的一种设计模式。该模型规定了类加载器在加载类时的委派关系,即每个类加载器都有一个父类加载器,类加载器在接收到类加载请求时,首先将请求委派给父类加载器处理,只有父类加载器无法处理时,子类加载器才会自己处理。这种模型确保了类加载的一致性和安全性,避免了类的重复加载和冲突。

双亲委派模型的工作原理

  1. 类加载器的层次结构

    JVM 中有多个类加载器,每个类加载器都有自己的职责。Java 的类加载器层次结构通常包括:

    • Bootstrap ClassLoader:顶级类加载器,负责加载 JDK 中的核心类库(如 rt.jar 中的类)。这个加载器通常是由 C++ 编写,直接与 JVM 相关联。
    • Extension ClassLoader:负责加载 JDK 中扩展库(如 lib/ext 目录下的类)。它的父类加载器是 Bootstrap ClassLoader。
    • System ClassLoader:也称为应用程序类加载器,负责加载应用程序的类路径(通常是 classpath 下的类)。它的父类加载器是 Extension ClassLoader。
    • 自定义类加载器:用户可以通过继承 ClassLoader 来创建自己的类加载器,用于加载应用程序特定的类。
  2. 委派过程

    • 当一个类加载器接收到一个类加载请求时,它首先检查是否已经加载过该类。如果已经加载,它就直接返回。
    • 如果该类还没有被加载,类加载器会将加载请求委派给父类加载器。父类加载器会采用同样的方式处理请求。
    • 只有当父类加载器无法加载时,子类加载器才会自行加载该类。
    • 这种委派机制保证了类的加载顺序和一致性。例如,应用程序的类不能覆盖 JDK 中的核心类,避免了类的冲突。

为什么需要双亲委派模型?

  1. 保证核心类的安全性和一致性

    双亲委派模型的最大优势是保证了 Java 核心类(如 java.lang.*java.util.* 等)不被用户的自定义类覆盖。由于所有的类加载请求都首先委派给 Bootstrap ClassLoader 处理,因此 JDK 核心库的类始终由顶层加载器加载,防止了应用程序类意外覆盖这些核心类。例如,如果没有双亲委派机制,应用程序就可能会加载一个与 JDK 核心库同名的类,这样就可能导致系统崩溃或错误的行为。

  2. 防止类加载的重复和冲突

    双亲委派模型避免了类的重复加载。通过让父加载器先尝试加载类,确保了类的加载不会被多个加载器重复执行。这样,类加载器可以通过委派关系来保证每个类只加载一次。

  3. 提高类加载的规范性和可维护性

    通过这种模型,类加载器的层次结构更加清晰,易于理解和维护。每个类加载器都有其固定的职责和加载范围,避免了类加载混乱和重复的情况。这种清晰的设计也使得 Java 在多层次、多模块的环境中表现得更加稳定和高效。

  4. 支持自定义类加载器

    双亲委派模型不仅确保了系统核心类的加载安全性,还为开发者提供了灵活的扩展性。开发者可以创建自定义的类加载器,通过实现自己的加载逻辑来加载应用程序特定的类,而不必担心覆盖 Java 核心库类或引发类加载冲突。

双亲委派模型的实际应用

  1. 应用程序的类加载

    在实际应用中,应用程序的类通常由系统类加载器(即应用程序类加载器)来加载。但是如果用户的类有冲突或依赖于其他库,系统类加载器会将请求委派给 Extension ClassLoader 或 Bootstrap ClassLoader 进行处理。

  2. 自定义类加载器

    自定义类加载器是通过继承 ClassLoader 类实现的。在开发 Java EE 应用或集成某些第三方框架时,经常需要自定义类加载器来加载特定的类。自定义加载器通常会使用双亲委派模型,从而确保核心类不会被覆盖。

  3. 动态加载和隔离

    双亲委派模型可以帮助管理不同版本的类库。通过使用不同的类加载器加载不同版本的类,可以实现类库的隔离和版本管理。例如,某些复杂的 Java Web 容器(如 Tomcat)通过不同的类加载器加载 Web 应用和容器自己的类,以避免版本冲突和类覆盖。

总结

双亲委派模型是一种保障 Java 类加载器安全性、规范性和高效性的设计模式。通过将类加载请求委派给父类加载器,Java 保证了核心类不会被应用程序的类覆盖,避免了类的冲突和重复加载问题。这个模型不仅为 Java 系统提供了一个稳定的类加载机制,也支持了用户自定义类加载器,从而提供了灵活的扩展能力。在实际开发中,了解并遵循双亲委派模型,能够帮助开发者高效地管理类加载过程,并确保 Java 应用程序的稳定运行。


⬅️ java的优势 🏠 00-Java ➡️ 字节码